Skip to content

fix(owlgen): warn on single-child covering axiom equivalence#5

Open
jdsika wants to merge 1 commit intomainfrom
fix/owlgen-single-child-covering-axiom-upstream
Open

fix(owlgen): warn on single-child covering axiom equivalence#5
jdsika wants to merge 1 commit intomainfrom
fix/owlgen-single-child-covering-axiom-upstream

Conversation

@jdsika
Copy link
Copy Markdown

@jdsika jdsika commented Mar 26, 2026

Summary

Emit a warning when an abstract class has only one direct is_a child, since the covering axiom degenerates to an equivalence (Parent ≡ Child). The warning recommends --skip-abstract-class-as-unionof-subclasses to suppress covering axioms if the equivalence is undesired.

Motivation

The covering axiom feature (linkml#3219) correctly expresses that every instance of an abstract class must belong to one of its subclasses. With a single child, this is semantically equivalent to Parent ≡ Child per OWL 2 Primer §4.2. While OWL-correct, this may surprise users building extensible ontologies where more children are added downstream — the equivalence causes new siblings to inherit constraints from the first child via RDFS transitivity.

Rather than silently skipping the axiom (which would change the OWL semantics), a warning alerts users to the implication and points them to the existing escape hatch.

Changes

  • owlgen.py: Add logger.warning() when an abstract class has exactly 1 child before the covering axiom is emitted. Update field docstring and CLI help text to document the warning.
  • test_owlgen.py: Add test_abstract_class_with_single_child_emits_warning (validates warning is logged with flag recommendation) and test_single_child_warning_suppressed_by_skip_flag (validates no warning when skip flag is set).

Design Decision

Per review feedback from @mgskjaeveland (linkml#3309): the single-child equivalence is not a generator error — it is the expected OWL semantics. The proper response is a warning, not a silent skip. Users who need to avoid the equivalence should use --skip-abstract-class-as-unionof-subclasses.

References

@jdsika jdsika force-pushed the fix/owlgen-single-child-covering-axiom-upstream branch from ece07d8 to c2b7088 Compare March 26, 2026 19:47
@jdsika jdsika changed the title fix(owlgen): skip covering axiom for abstract class with single child fix(owlgen): warn on single-child covering axiom equivalence Mar 26, 2026
@jdsika jdsika force-pushed the fix/owlgen-single-child-covering-axiom-upstream branch 3 times, most recently from 8841b3d to 2b9e4e9 Compare March 30, 2026 12:12
@jdsika
Copy link
Copy Markdown
Author

jdsika commented Apr 2, 2026

🔍 Adversarial Review — PR #5

Summary

Solid, well-motivated PR. The OWL 2 semantics analysis is correct and the warning-not-skip approach aligns with upstream reviewer feedback. No actual bugs found — but there are test coverage gaps and a couple of design concerns worth discussing before merge.


🐛 Bugs & Issues

No code bugs found. The control flow is correct:

if not children → warn (no axiom)
elif len == 1  → warn (equivalence)
if children    → emit axiom (for 1+ children)

The if/elif warning block and the separate if children: axiom block are intentionally decoupled — 1-child case fires both the elif warning AND the if children: axiom. This is correct per the design decision.

One correctness note on the test assertion:

test_abstract_class_with_single_child_emits_warning asserts (EX.Parent, RDFS.subClassOf, EX.Child) in g. This is correct because _boolean_expression returns exprs[0] directly for single-element lists (line ~1325), so _union_of([child_uri])child_uri, producing the raw Parent rdfs:subClassOf Child triple (no owl:unionOf wrapper). Combined with Child rdfs:subClassOf Parent from is_a, this is indeed bidirectional subClassOf = equivalence per OWL 2 §4.2. ✅


⚠️ Concerns

  1. No-children warning suppressed by skip flag — intentional?
    When skip_abstract_class_as_unionof_subclasses=True, the no-children warning is also suppressed (tested by test_no_children_warning_suppressed_by_skip_flag). An orphan abstract class (no subclasses at all) might be worth warning about regardless of the skip flag — it suggests an incomplete class hierarchy, not just a covering axiom concern. Consider whether this warning should live outside the if not self.skip_abstract_class_as_unionof_subclasses: guard.

  2. CLI help text ambiguity:
    The added text reads: "Note: warnings are emitted for abstract classes with zero children (no axiom) or one child (equivalence)."
    This doesn't clarify that warnings fire when the flag is NOT set. A reader might wonder if warnings still appear with --skip-abstract-class-as-unionof-subclasses. Suggest: "Note: without this flag, warnings are emitted for abstract classes with zero children (no axiom) or one child (equivalence)."

  3. Unicode in warning message:
    "The covering axiom makes them equivalent (%s ≡ %s)." — the symbol could cause UnicodeEncodeError in non-UTF-8 terminals or CI log sinks. Consider (%s == %s) or (%s ≡ %s [equivalentClass]) with an ASCII fallback, or just spell it out: "making them equivalent".

  4. _union_of single-element optimization is the root cause:
    The real issue is that _boolean_expression silently collapses single-element unions into the bare element. For covering axioms, this transforms Parent ⊑ unionOf(Child) (which is just Parent ⊑ Child) into a raw rdfs:subClassOf triple that's indistinguishable from a manually-asserted subsumption. A future PR might consider explicitly emitting owl:equivalentClass when len(children) == 1, making the intent machine-readable. (Out of scope for this PR, but worth a tracking issue.)


🧪 Test Coverage Assessment

Covered well:

  • ✅ 0 children → warning + no axiom
  • ✅ 0 children + skip flag → no warning
  • ✅ 1 child → warning + axiom emitted (with bidirectional subClassOf check)
  • ✅ 1 child + skip flag → no warning + no axiom

Missing scenarios:

  1. 2+ children → no warning emitted (negative test). test_abstract_class_gets_union_of_subclasses_by_default exists on main but doesn't use caplog to verify absence of warnings. A regression could add spurious warnings for the normal multi-child case without any test catching it.

  2. Non-abstract class with 1 child → no warning. test_non_abstract_class_does_not_get_union_of_axiom exists but doesn't check warnings. A concrete class with a single child should never trigger the equivalence warning.

  3. Abstract class with mixins (not is_a) children. The code uses mixins=False, is_a=True — a class with mixin-only "children" should get 0 is_a children and trigger the no-children warning. This edge case is untested.

Style nit: All 4 tests do import logging inside the function body. logging is a stdlib module — just add it to the module-level imports alongside pytest, rdflib, etc.


📋 Fix Plan

  1. Add negative warning test for 2+ children:

    def test_abstract_class_with_multiple_children_no_warning(caplog):
        import logging
        with caplog.at_level(logging.WARNING, logger="linkml.generators.owlgen"):
            g = _owl_graph(_build_abstract_schema())  # Animal -> Dog, Cat
        assert _union_members(g, EX.Animal) is not None
        assert not any("equivalent" in msg.lower() for msg in caplog.messages)
        assert not any("has no children" in msg for msg in caplog.messages)
  2. Clarify CLI help text — prepend "without this flag" to the Note sentence.

  3. Move import logging to module-level in the test file.

  4. Consider decoupling no-children warning from skip flag (design discussion — not blocking).

  5. Open tracking issue for explicit owl:equivalentClass emission when single-child covering axiom is detected (future improvement).


✅ What's Good

  • OWL 2 semantics are correct — the equivalence claim (Parent ≡ Child from bidirectional rdfs:subClassOf) is well-grounded in OWL 2 Primer §4.2 and OWL 2 Syntax §9.1.4.
  • Warn-don't-skip design respects @mgskjaeveland's upstream feedback on fix(owlgen): warn on single-child covering axiom equivalence linkml/linkml#3309 — the axiom IS semantically correct, so suppressing it silently would be wrong.
  • Lazy %-formatting for logger.warning() — correct Python logging style (no eager f-string evaluation).
  • logger = logging.getLogger(__name__) — properly configured, matches the caplog logger name in tests.
  • Thorough inline comments explaining the equivalence derivation.
  • PR description is excellent — cites specs, references upstream issues, documents the design decision.

Emit warnings for abstract class covering axiom edge cases:

- Zero children: warn that no covering axiom will be generated
- One child: warn that the covering axiom degenerates to an equivalence
  (Parent = Child), recommending --skip-abstract-class-as-unionof-subclasses

Both axioms are still emitted when applicable (semantically correct per
OWL 2), but warnings alert users who extend the ontology downstream.

Tests verify warnings are logged, flag suppression works, the
single-child covering axiom triple is correctly asserted, plus
negative tests for multi-child and concrete class cases, and the
mixin-only children edge case.

Refs: linkml#3309, linkml#3219
Signed-off-by: jdsika <carlo.van-driesten@bmw.de>
@jdsika jdsika force-pushed the fix/owlgen-single-child-covering-axiom-upstream branch from 7ac6437 to c7afa6e Compare April 2, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant